]> dgit.raspbian.org Git - gnome-shell.git/commitdiff
timeLimitsManager: Integrate with malcontent and lock when limit reached
authorIgnacy Kuchciński <ignacykuchcinski@gnome.org>
Mon, 18 Aug 2025 09:58:50 +0000 (11:58 +0200)
committerIgnacy Kuchciński <ignacykuchcinski@gnome.org>
Wed, 5 Nov 2025 14:22:12 +0000 (15:22 +0100)
Lock the screen when screen time limit is reached in a child session,
which required extending the existing TimeLimitsManager functionality to
include the parental controls session limits.

TimeLimitsDispatcher was also adjusted to take into account parental
controls session limits before making any user facing changes.

Closes: https://gitlab.gnome.org/GNOME/gnome-shell/-/work_items/8575
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3841>

js/misc/timeLimitsManager.js
tests/unit/timeLimitsManager.js

index b99939778a0cffa2b628b43f5708507cfd342d48..c0dd97ce9d66732cb076077353c54befc48f01f7 100644 (file)
@@ -32,6 +32,13 @@ import * as Gettext from 'gettext';
 import * as LoginManager from './loginManager.js';
 import * as Main from '../ui/main.js';
 import * as MessageTray from '../ui/messageTray.js';
+import * as ParentalControlsManager from './parentalControlsManager.js';
+import * as SystemActions from './systemActions.js';
+
+import {loadInterfaceXML} from './fileUtils.js';
+
+const TimerChildIface = loadInterfaceXML('org.freedesktop.MalcontentTimer1.Child');
+const TimerChildProxy = Gio.DBusProxy.makeProxyWrapper(TimerChildIface);
 
 export const HISTORY_THRESHOLD_SECONDS = 14 * 7 * 24 * 60 * 60;  // maximum time history entries are kept
 const LIMIT_UPCOMING_NOTIFICATION_TIME_SECONDS = 10 * 60;  // notify the user 10min before their limit is reached
@@ -79,10 +86,17 @@ function userStateToString(userState) {
 
 /**
  * A manager class which tracks total active/inactive time for a user, and
+ * signals when the user has reached their session time limit for actively
+ * using the device if the user has parental controls session limits set. When
+ * they are disabled, the wellbeing daily time limit is considered, and manager
  * signals when the user has reached their daily time limit for actively using
  * the device, if limits are enabled.
  *
- * Active/Inactive time is based off the total time the user account has spent
+ * For parental controls session time limit, the manager tracks the active/inactive
+ * time via malcontent-timerd.
+ *
+ * For wellbeing daily time limit, the way in which the manager tracks the
+ * active/inactive time is based off the total time the user account has spent
  * logged in to at least one active session, not idle (and not locked, but
  * that’s a subset of idle time), and not suspended.
  * This corresponds to the `active` state from sd_uid_get_state()
@@ -123,7 +137,7 @@ export const TimeLimitsManager = GObject.registerClass({
         'daily-limit-reached': {},
     },
 }, class TimeLimitsManager extends GObject.Object {
-    constructor(historyFile, clock, loginManagerFactory, loginUserFactory, settingsFactory) {
+    constructor(historyFile, clock, loginManagerFactory, loginUserFactory, settingsFactory, parentalControlsManagerFactory, timerChildProxyFactory) {
         super();
 
         // Allow these few bits of global state to be overridden for unit testing
@@ -154,6 +168,37 @@ export const TimeLimitsManager = GObject.registerClass({
                 return loginManager.getCurrentUserProxy();
             },
         };
+
+        this._parentalControlsManagerFactory = parentalControlsManagerFactory ?? {
+            new: ParentalControlsManager.getDefault,
+        };
+        this._parentalControlsManager = this._parentalControlsManagerFactory.new();
+
+        this._parentalControlsManager.connectObject(
+            'session-limits-changed', () => this._onSessionLimitsChanged().catch(logError),
+            this);
+
+        this._estimatedTimes = [];
+        this._timerChildProxyFactory = timerChildProxyFactory ?? {
+            new: () => {
+                return TimerChildProxy(Gio.DBus.system,
+                    'org.freedesktop.MalcontentTimer1',
+                    '/org/freedesktop/MalcontentTimer1',
+                    (proxy, error) => {
+                        if (error)
+                            console.debug(`Failed to get TimerChild proxy: ${error}`);
+                    },
+                    null, /* cancellable */
+                    Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION
+                );
+            },
+        };
+
+        this._timerChildProxy = this._timerChildProxyFactory.new();
+
+        this._timerChildProxy.connectSignal('EstimatedTimesChanged',
+            () => this._updateEstimatedTimes().catch(logError));
+
         this._settingsFactory = settingsFactory ?? {
             new: Gio.Settings.new,
         };
@@ -176,13 +221,68 @@ export const TimeLimitsManager = GObject.registerClass({
         this._timeChangeId = 0;
         this._clockOffsetSecs = 0;
         this._ignoreClockOffsetChanges = false;
+        this._latestUsageEndSecs = 0;
 
         // Start tracking timings
         this._updateStateMachine();
     }
 
+    async _recordUsage(startSecs, endSecs) {
+        console.debug('Recording usage to the parental controls daemon: ' +
+            `${this._unixToString(startSecs)} ${this._unixToString(endSecs)}`);
+        try {
+            await this._timerChildProxy.RecordUsageAsync(
+                [[startSecs, endSecs, 'login-session', '']]);
+            this._latestUsageEndSecs = endSecs;
+        } catch (e) {
+            if (e.matches(Gio.DBusError, Gio.DBusError.SERVICE_UNKNOWN)) {
+                // Limits should only be enabled when the daemon is available.
+                if (this._parentalControlsManager.sessionLimitsEnabled())
+                    throw e;
+                else
+                    console.debug('Parental controls timer daemon not available');
+            } else {
+                console.warn(`Failed to record usage: ${e.message}`);
+            }
+        }
+    }
+
+    async _onSessionLimitsChanged() {
+        // New session limits may mean that the state machine will need to start/stop.
+        this._updateStateMachine();
+
+        // Checkpoint current active usage to the parental controls timer daemon,
+        // so that it can make a correct estimate of the remaining screen time.
+        // Wellbeing monitoring uses the stored edge events (state transitions),
+        // whereas the parental controls timer daemon stores time periods only after
+        // one is finished, which is why it cannot know the start of the usage period,
+        // without recording it first. Recording will trigger EstimatedTimesChanged
+        // signal, which handler updates the estimated times and state.
+        if (this._stateTransitions.length > 0 &&
+            this._stateTransitions.at(-1).newState === UserState.ACTIVE) {
+            const startSecs = this._stateTransitions.at(-1).wallTimeSecs;
+            const endSecs = this.getCurrentTime();
+            await this._recordUsage(startSecs, endSecs);
+        }
+    }
+
+    async _updateEstimatedTimes() {
+        const [, timesSecs] = await this._timerChildProxy.GetEstimatedTimesAsync('login-session');
+        if (timesSecs[''])
+            this._estimatedTimes = timesSecs[''];
+        else
+            this._estimatedTimes = [];
+
+        this._updateState();
+    }
+
+    _storingTransitionsEnabled() {
+        return this._parentalControlsManager.sessionLimitsEnabled() ||
+            this._screenTimeLimitSettings.get_boolean('history-enabled');
+    }
+
     _updateStateMachine() {
-        if (!this._screenTimeLimitSettings.get_boolean('history-enabled')) {
+        if (!this._storingTransitionsEnabled()) {
             if (this._state !== TimeLimitsState.DISABLED) {
                 this._stopStateMachine().catch(
                     e => console.warn(`Failed to stop state machine: ${e.message}`));
@@ -308,7 +408,7 @@ export const TimeLimitsManager = GObject.registerClass({
         this._lastStateChangeTimeSecs = 0;
         this.notify('state');
 
-        if (this._screenTimeLimitSettings.get_boolean('history-enabled')) {
+        if (this._storingTransitionsEnabled()) {
             // Add a fake transition to show the shutdown.
             if (this._userState !== UserState.INACTIVE) {
                 const nowSecs = this.getCurrentTime();
@@ -497,6 +597,13 @@ export const TimeLimitsManager = GObject.registerClass({
      * if the system real time clock changes (relative to the monotonic clock).
      */
     async _loadTransitions() {
+        // When parental controls session limits are enabled, additionally update
+        // the cached estimated times from the parental controls timer daemon.
+        if (this._parentalControlsManager.sessionLimitsEnabled()) {
+            await this._updateEstimatedTimes();
+            console.debug('TimeLimitsManager: Loaded time estimates from the timer daemon');
+        }
+
         const file = this._historyFile;
 
         let contents;
@@ -605,6 +712,30 @@ export const TimeLimitsManager = GObject.registerClass({
 
         this._stateTransitions = newTransitions;
 
+        // Record all of the usage records to the parental controls timer daemon
+        // if the parental controls session limits are in place.
+        if (this._parentalControlsManager.sessionLimitsEnabled()) {
+            for (var j = 0; j < this._stateTransitions.length - 1; j++) {
+                const start = this._stateTransitions[j];
+                const end = this._stateTransitions[j + 1];
+
+                // Make sure the transition is correct
+                if (start['newState'] !== UserState.ACTIVE ||
+                    end['newState'] !== UserState.INACTIVE)
+                    continue;
+
+                const startSecs = start['wallTimeSecs'];
+                const endSecs = end['wallTimeSecs'];
+
+                // Avoid re-sending the old state transitions
+                if (endSecs <= this._latestUsageEndSecs)
+                    continue;
+
+                /* eslint-disable-next-line no-await-in-loop */
+                await this._recordUsage(startSecs, endSecs);
+            }
+        }
+
         if (this._stateTransitions.length === 0) {
             try {
                 await file.delete(this._cancellable);
@@ -775,24 +906,63 @@ export const TimeLimitsManager = GObject.registerClass({
         if (startOfTodaySecs > this._lastStateChangeTimeSecs)
             newState = TimeLimitsState.ACTIVE;
 
+        const [, currentSessionStart, currentSessionEnd, nextSessionStart] = this._estimatedTimes;
+        const sessionLimitsEnabled = this._parentalControlsManager.sessionLimitsEnabled();
+
+        if (sessionLimitsEnabled) {
+            // Parental controls session limits have either just been enabled
+            // and the estimated times have not been cached yet, or disabled
+            // but the manager has not detected that yet, so skip updating state.
+            if (nextSessionStart === undefined || currentSessionEnd === undefined)
+                return;
+        }
+
+        const sessionLimitsDebug = sessionLimitsEnabled ? 'current session start: ' +
+            `${this._unixToString(currentSessionStart)}, current session end: ` +
+            `${this._unixToString(currentSessionEnd)}` : 'disabled';
+        console.debug('TimeLimitsManager: Parental controls session limits: ' +
+            `${sessionLimitsDebug}`);
+
         // Work out how much time the user has spent at the screen today.
         const activeTimeTodaySecs = this._calculateActiveTimeTodaySecs(nowSecs, startOfTodaySecs);
         const dailyLimitSecs = this._screenTimeLimitSettings.get_uint('daily-limit-seconds');
         const dailyLimitEnabled = this._screenTimeLimitSettings.get_boolean('daily-limit-enabled');
 
         const dailyLimitDebug = dailyLimitEnabled ? `${dailyLimitSecs}s` : 'disabled';
-        console.debug('TimeLimitsManager: Active time today: ' +
+        console.debug('TimeLimitsManager: Wellbeing active time today: ' +
             `${activeTimeTodaySecs}s, daily limit ${dailyLimitDebug}`);
 
-        if (dailyLimitEnabled && activeTimeTodaySecs >= dailyLimitSecs) {
+
+        // Update TimeLimitsState. When the user is inactive, there's no point
+        // scheduling anything until they become active again.
+        if (sessionLimitsEnabled) {
+            if (nextSessionStart <= nowSecs) {
+                // Just entered daily schedule, so update cached estimated times
+                // which will perform state update afterwards.
+                this._updateEstimatedTimes();
+                return;
+            }
+
+            if (nowSecs >= currentSessionEnd) {
+                newState = TimeLimitsState.LIMIT_REACHED;
+
+                // Schedule an update for when the session limit will be reset again.
+                this._scheduleUpdateState(nextSessionStart - nowSecs);
+            } else if (this._userState === UserState.ACTIVE) {
+                newState = TimeLimitsState.ACTIVE;
+
+                // Schedule an update for when we expect the session limit will be reached.
+                this._scheduleUpdateState(currentSessionEnd - nowSecs);
+            }
+        } else if (dailyLimitEnabled && activeTimeTodaySecs >= dailyLimitSecs) {
             newState = TimeLimitsState.LIMIT_REACHED;
 
-            // Schedule an update for when the limit will be reset again.
+            // Schedule an update for when the daily limit will be reset again.
             this._scheduleUpdateState(startOfTomorrowSecs - nowSecs);
         } else if (this._userState === UserState.ACTIVE) {
             newState = TimeLimitsState.ACTIVE;
 
-            // Schedule an update for when we expect the limit will be reached.
+            // Schedule an update for when we expect the daily limit will be reached.
             if (dailyLimitEnabled)
                 this._scheduleUpdateState(dailyLimitSecs - activeTimeTodaySecs);
         } else {
@@ -802,6 +972,9 @@ export const TimeLimitsManager = GObject.registerClass({
 
         // Update the saved state.
         if (newState !== this._state) {
+            console.debug('TimeLimitsManager: State changed from ' +
+                          `${timeLimitsStateToString(this._state)} ` +
+                          `to ${timeLimitsStateToString(newState)}`);
             this._state = newState;
             this._lastStateChangeTimeSecs = nowSecs;
             this.notify('state');
@@ -838,6 +1011,15 @@ export const TimeLimitsManager = GObject.registerClass({
         return this._state;
     }
 
+    /**
+     * Whether the parental controls session limits are enabled.
+     *
+     * @type {boolean}
+     */
+    get sessionLimitsEnabled() {
+        return this._parentalControlsManager.sessionLimitsEnabled();
+    }
+
     /**
      * The time when the daily limit will be reached. If the user is currently
      * active, and has not reached the limit, this is a non-zero value in the
@@ -888,8 +1070,6 @@ export const TimeLimitsManager = GObject.registerClass({
     /**
      * Whether the daily limit is enabled.
      *
-     * If false, screen usage information is recorded, but no limit is enforced.
-     *
      * @type {boolean}
      */
     get dailyLimitEnabled() {
@@ -911,13 +1091,15 @@ export const TimeLimitsManager = GObject.registerClass({
  * Glue class which takes the state-based output from TimeLimitsManager and
  * converts it to event-based notifications for the user to tell them
  * when their time limit has been reached. It factors the user’s UI preferences
- * into account.
+ * into account, and whether parental controls session limits are enabled.
  */
 export const TimeLimitsDispatcher = GObject.registerClass(
 class TimeLimitsDispatcher extends GObject.Object {
     constructor(manager) {
         super();
 
+        this._systemActions = new SystemActions.getDefault();
+
         this._manager = manager;
         this._manager.connectObject(
             'notify::state', this._onStateChanged.bind(this),
@@ -981,6 +1163,16 @@ class TimeLimitsDispatcher extends GObject.Object {
         }
 
         case TimeLimitsState.LIMIT_REACHED: {
+            if (this._manager.sessionLimitsEnabled) {
+                // Trying to lock the screen will fail if the action is not
+                // available. This happens when admin disables lock screen,
+                // or when not running under GDM/logind. Those are corner cases
+                // which don’t need to be handled.
+                if (this._systemActions.canLockScreen)
+                    this._systemActions.activateLockScreen();
+                break;
+            }
+
             this._ensureEnabled();
 
             if (this._manager.grayscaleEnabled) {
@@ -1149,8 +1341,10 @@ class TimeLimitsNotificationSource extends GObject.Object {
 
         case TimeLimitsState.LIMIT_REACHED: {
             // Notify the user that they’ve reached their limit, when we
-            // transition from any state to LIMIT_REACHED.
-            if (this._previousState !== TimeLimitsState.LIMIT_REACHED) {
+            // transition from any state to LIMIT_REACHED. Noop if parental
+            // controls session limits are enabled, since we’re locking then.
+            if (!this._manager.sessionLimitsEnabled &&
+                this._previousState !== TimeLimitsState.LIMIT_REACHED) {
                 this._ensureNotification({
                     title: _('Screen Time Limit Reached'),
                     body: _('It’s time to stop using the device'),
index 92cba542b579ff89e998ecdc9be9c427bdd37973..d7c5a1724abb12893be23eda26eef4c38dccf15d 100644 (file)
@@ -36,7 +36,9 @@ const {TimeLimitsState, UserState} = TimeLimitsManager;
  * of time, maintaining an internal ordered queue of events, and providing three
  * groups of mock functions which the `TimeLimitsManager` uses to interact with
  * it: mock versions of GLib’s clock and timeout functions, a mock proxy of the
- * logind `User` D-Bus object, and a mock version of `Gio.Settings`.
+ * logind `User` D-Bus object, a mock proxy around `Malcontent.Manager` object,
+ * a mock proxy of the malcontent-timerd `Child` D-Bus object, and a mock
+ * version of `Gio.Settings`.
  *
  * The internal ordered queue of events is sorted by time (in real/wall clock
  * seconds, i.e. UNIX timestamps). On each _tick(), the next event is shifted
@@ -53,7 +55,7 @@ const {TimeLimitsState, UserState} = TimeLimitsManager;
  * daily time limit is reset at a specific time each morning.
  */
 class TestHarness {
-    constructor(settings, historyFileContents = null) {
+    constructor(settings, historyFileContents = null, sessionLimitsEnabled = false) {
         this._currentTimeSecs = 0;
         this._clockOffset = 100;  // make the monotonic clock lag by 100s, arbitrarily
         this._nextSourceId = 1;
@@ -65,6 +67,8 @@ class TestHarness {
         this._settingsChangedDailyLimitSecondsCallback = null;
         this._settingsChangedDailyLimitEnabledCallback = null;
         this._settingsChangedGrayscaleCallback = null;
+        this._parentalControlsManagerSessionLimitsChangedCallback = null;
+        this._timerChildProxyEstimatedTimesChangedCallback = null;
 
         // These two emulate relevant bits of the o.fdo.login1.User API
         // See https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User%20Objects
@@ -88,6 +92,13 @@ class TestHarness {
 
         this._historyFile = file;
 
+        this._sessionLimitsEnabled = sessionLimitsEnabled;
+        this._limitReached = false;
+        this._currentSessionStartTimeSecs = 0;
+        this._currentSessionEstimatedEndTimeSecs = GLib.MAX_UINT64;
+        this._nextSessionStartTimeSecs = GLib.MAX_UINT64;
+        this._nextSessionEstimatedEndTimeSecs = GLib.MAX_UINT64;
+
         // And a mock D-Bus proxy for logind.
         const harness = this;
 
@@ -316,6 +327,55 @@ class TestHarness {
         });
     }
 
+    /**
+     * Add a parental controls manager session limits changed event to the event
+     * queue. This simulates the `session-limits-changed` signal of the
+     * `Malcontent.Manager` object, notifying gnome-shell that the user’s
+     * session limits were changed, for example from malcontent-control.
+     *
+     * @param {string} timeStr Date/Time the event happens, in ISO 8601 format.
+     * @param {boolean} newSessionLimitsEnabled New session limits enabled setting.
+     */
+    addParentalControlsManagerSessionLimitsChangedEvent(timeStr, newSessionLimitsEnabled) {
+        return this._insertEvent({
+            type: 'parental-controls-manager-session-limits-changed',
+            time: TestHarness.timeStrToSecs(timeStr),
+            newSessionLimitsEnabled,
+        });
+    }
+
+    /**
+     * Add a timer child proxy estimated times changed event to the event
+     * queue. This simulates the `EstimatedTimesChanged` signal of the
+     * `org.freedesktop.MalcontentTimer1.Child` object, notifying gnome-shell
+     * that the user’s estimated session times were changed, for example as a
+     * result of gnome-shell recording usage.
+     *
+     * @param {string} timeStr Date/Time the event happens, in ISO 8601 format.
+     * @param {boolean} newLimitReached New limit reached setting.
+     * @param {number} newCurrentSessionStartTimeSecs New current session start
+     *    time in seconds.
+     * @param {number} newCurrentSessionEstimatedEndTimeSecs New current session
+     *    estimated end time in seconds.
+     * @param {number} newNextSessionStartTimeSecs New next session start time
+     *    in seconds.
+     * @param {number} newNextSessionEstimatedEndTimeSecs New next session
+     *    estimated end time in seconds.
+     */
+    addTimerChildProxyEstimatedTimesChangedEvent(timeStr, newLimitReached,
+        newCurrentSessionStartTimeSecs, newCurrentSessionEstimatedEndTimeSecs,
+        newNextSessionStartTimeSecs, newNextSessionEstimatedEndTimeSecs) {
+        return this._insertEvent({
+            type: 'timer-child-proxy-estimated-times-changed',
+            time: TestHarness.timeStrToSecs(timeStr),
+            newLimitReached,
+            newCurrentSessionStartTimeSecs,
+            newCurrentSessionEstimatedEndTimeSecs,
+            newNextSessionStartTimeSecs,
+            newNextSessionEstimatedEndTimeSecs,
+        });
+    }
+
     /**
      * Add a settings change event to the event queue. This simulates dconf
      * notifying gnome-shell that the user has changed a setting, for example
@@ -410,6 +470,29 @@ class TestHarness {
         });
     }
 
+    /**
+     * Add a state assertion event to the event queue. This is a specialised
+     * form of `addAssertionEvent()` which asserts that the given
+     * start and end date/times of the most recent usage recording equal the
+     * expected values at date/time `timeStr`.
+     *
+     * @param {string} timeStr Date/Time to check the times at, in ISO 8601
+     *    format.
+     * @param {string} startStr Start date/time in ISO 8601 format.
+     * @param {string} endStr End date/time in ISO 8601 format.
+     */
+    expectUsage(timeStr, startStr, endStr) {
+        return this.addAssertionEvent(timeStr, () => {
+            const [start, end] = this._timeStore;
+            expect(start)
+                .withContext('start')
+                .toEqual(TestHarness.timeStrToSecs(startStr));
+            expect(end)
+                .withContext('end')
+                .toEqual(TestHarness.timeStrToSecs(endStr));
+        });
+    }
+
     _popEvent() {
         return this._events.shift();
     }
@@ -490,7 +573,9 @@ class TestHarness {
             this.mockClock,
             this.mockLoginManagerFactory,
             this.mockLoginUserFactory,
-            this.mockSettingsFactory);
+            this.mockSettingsFactory,
+            this.mockParentalControlsManagerFactory,
+            this.mockTimerChildProxyFactory);
     }
 
     /**
@@ -529,6 +614,92 @@ class TestHarness {
         };
     }
 
+    /**
+     * Get a mock parental controls manager factory for use in the
+     * `TimeLimitsManager` under test. This is an object providing constructors
+     * for `ParentalControlsManager` objects, which are proxies around the
+     * `Malcontent.Manager` objects. Each constructor returns a basic
+     * implementation of `ParentalControlsManager` which uses the daily limit
+     * and daily schedule variables passed to `TestHarness` in its constructor
+     * in the settings dictionary.
+     *
+     * This has an extra layer of indirection to match `mockSettingsFactory`.
+     */
+    get mockParentalControlsManagerFactory() {
+        return {
+            new: () => {
+                return {
+                    connectObject: (...args) => {
+                        const [
+                            sessionLimitsChangedStr, sessionLimitsChangedCallback,
+                            obj,
+                        ] = args;
+
+                        if (sessionLimitsChangedStr !== 'session-limits-changed' ||
+                            typeof obj !== 'object')
+                            fail('ParentalControlsManager.connectObject() not called in expected way');
+                        if (this._parentalControlsManagerSessionLimitsChangedCallback !== null)
+                            fail('ParentalControlsManager signals already connected');
+
+                        this._parentalControlsManagerSessionLimitsChangedCallback = sessionLimitsChangedCallback;
+                    },
+                    sessionLimitsEnabled: () => {
+                        return this._sessionLimitsEnabled;
+                    },
+                };
+            },
+        };
+    }
+
+    /**
+     * Get a mock timer child proxy factory for use in the `TimeLimitsManager`
+     * under test. This is an object providing constructors for `TimerChildProxy`
+     * objects, which are proxies around the `org.freedesktop.MalcontentTimer1.Child`
+     * D-Bus API. Each constructor returns a basic implementation of `TimerChildProxy`.
+     *
+     * This has an extra layer of indirection to match `mockSettingsFactory`.
+     */
+    get mockTimerChildProxyFactory() {
+        return {
+            new: () => {
+                return {
+                    connectSignal: (...args) => {
+                        const [
+                            estimatedTimesChangedStr, estimatedTimesChangedCallback,
+                        ] = args;
+
+                        if (estimatedTimesChangedStr !== 'EstimatedTimesChanged')
+                            fail('TimerChildProxy.connectSignal() not called in expected way');
+                        if (this._timerChildProxyEstimatedTimesChangedCallback !== null)
+                            fail('TimerChildProxy signals already connected');
+
+                        this._timerChildProxyEstimatedTimesChangedCallback = estimatedTimesChangedCallback;
+                    },
+                    RecordUsageAsync: args => {
+                        return new Promise(resolve => {
+                            const [[startSecs, endSecs]] = args;
+                            this._timeStore = [startSecs, endSecs];
+                            resolve();
+                        });
+                    },
+                    GetEstimatedTimesAsync: _ => {
+                        return new Promise(resolve => {
+                            resolve([0, {
+                                '': [
+                                    this._limitReached,
+                                    this._currentSessionStartTimeSecs,
+                                    this._currentSessionEstimatedEndTimeSecs,
+                                    this._nextSessionStartTimeSecs,
+                                    this._nextSessionEstimatedEndTimeSecs,
+                                ],
+                            }]);
+                        });
+                    },
+                };
+            },
+        };
+    }
+
     /**
      * Get a mock settings factory for use in the `TimeLimitsManager` under test.
      * This is an object providing constructors for `Gio.Settings` objects. Each
@@ -621,6 +792,22 @@ class TestHarness {
             if (this._loginUserPropertiesChangedCallback)
                 this._loginUserPropertiesChangedCallback();
             break;
+        case 'parental-controls-manager-session-limits-changed':
+            this._sessionLimitsEnabled = event.newSessionLimitsEnabled;
+
+            if (this._parentalControlsManagerSessionLimitsChangedCallback)
+                this._parentalControlsManagerSessionLimitsChangedCallback();
+            break;
+        case 'timer-child-proxy-estimated-times-changed':
+            this._limitReached = event.newLimitReached;
+            this._currentSessionStartTimeSecs = event.newCurrentSessionStartTimeSecs;
+            this._currentSessionEstimatedEndTimeSecs = event.newCurrentSessionEstimatedEndTimeSecs;
+            this._nextSessionStartTimeSecs = event.newNextSessionStartTimeSecs;
+            this._nextSessionEstimatedEndTimeSecs = event.newNextSessionEstimatedEndTimeSecs;
+
+            if (this._timerChildProxyEstimatedTimesChangedCallback)
+                this._timerChildProxyEstimatedTimesChangedCallback();
+            break;
         case 'settings-change':
             this._settings[event.schemaId][event.key] = event.newValue;
 
@@ -724,6 +911,35 @@ describe('Time limits manager', () => {
         harness.run();
     });
 
+    it('cannot be disabled via GSettings if parental controls enabled', () => {
+        const harness = new TestHarness(
+            {
+                'org.gnome.desktop.screen-time-limits': {
+                    'history-enabled': false,
+                    'daily-limit-enabled': false,
+                    'daily-limit-seconds': 4 * 60 * 60,
+                },
+            },
+            null,
+            {
+                'parental-enabled': true,
+            }
+        );
+        harness.initializeMockClock('2024-06-01T10:00:00Z');
+        const timeLimitsManager = harness.createTimeLimitsManager();
+
+        harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+        harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+        harness.addLoginUserStateChangeEvent('2024-06-01T15:00:10Z', 'active', false);
+        harness.addLoginUserStateChangeEvent('2024-06-01T15:00:20Z', 'lingering', true);
+        harness.expectProperties('2024-06-01T15:00:30Z', timeLimitsManager, {
+            'state': TimeLimitsState.ACTIVE,
+        });
+        harness.shutdownManager('2024-06-01T15:10:00Z', timeLimitsManager);
+
+        harness.run();
+    });
+
     it('can be toggled on and off via GSettings', () => {
         const harness = new TestHarness({
             'org.gnome.desktop.screen-time-limits': {
@@ -781,6 +997,37 @@ describe('Time limits manager', () => {
         harness.run();
     });
 
+    it('tracks a single day’s usage with parental controls limit enabled', () => {
+        const harness = new TestHarness({
+            'org.gnome.desktop.screen-time-limits': {
+                'history-enabled': true,
+                'daily-limit-enabled': true,
+                'daily-limit-seconds': 4 * 60 * 60,
+            },
+        }, null, true);
+        harness.initializeMockClock('2024-06-01T10:00:00Z');
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-01T10:00:01Z',
+            false,
+            TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T03:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T10:00:02Z', true
+        );
+
+        const timeLimitsManager = harness.createTimeLimitsManager();
+
+        harness.expectState('2024-06-01T10:00:03Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+        harness.expectState('2024-06-01T12:59:59Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+        harness.expectState('2024-06-01T13:00:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+        harness.shutdownManager('2024-06-01T13:00:02Z', timeLimitsManager);
+
+        harness.run();
+    });
+
     it('tracks a single day’s usage early in the morning', () => {
         const harness = new TestHarness({
             'org.gnome.desktop.screen-time-limits': {
@@ -913,6 +1160,189 @@ describe('Time limits manager', () => {
         harness.run();
     });
 
+    it('resets usage if the parental controls session limit is changed', () => {
+        const harness = new TestHarness({
+            'org.gnome.desktop.screen-time-limits': {
+                'history-enabled': true,
+                'daily-limit-enabled': true,
+                'daily-limit-seconds': 4 * 60 * 60,
+            },
+        }, null, true);
+        harness.initializeMockClock('2024-06-01T10:00:00Z');
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-01T10:00:01Z',
+            false,
+            TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T03:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T10:00:02Z', true
+        );
+        const timeLimitsManager = harness.createTimeLimitsManager();
+
+        // Run before the limit is reached
+        harness.expectState('2024-06-01T10:00:03Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+        harness.expectState('2024-06-01T12:00:00Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+
+        // Change session limits part-way through the day in a way which means
+        // the child still has time left that day
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-01T12:30:00Z',
+            false,
+            TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T04:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T12:30:01Z', true
+        );
+
+        // Run before the limit is reached
+        harness.expectState('2024-06-01T12:30:02Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+        harness.expectState('2024-06-01T13:30:00Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+
+        // Change session limits part-way through the day in a way which means
+        // the child no longer has time left that day
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-01T13:45:00Z',
+            true,
+            TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T03:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T13:45:01Z', true
+        );
+
+        // Run before the limit is over
+        harness.expectState('2024-06-01T13:45:02Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+        harness.expectState('2024-06-01T14:00:00Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+
+        // Change session limits after the child has run out of time in a way
+        // which means the child has still run out of time
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-01T14:30:00Z',
+            true,
+            TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T04:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T14:30:01Z', true
+        );
+
+        // Run before the limit is over
+        harness.expectState('2024-06-01T14:30:02Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+        harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+
+        // Change session limits after the child has run out of time in a way
+        // which means the child has more time remaining
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-01T15:30:00Z',
+            true,
+            TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-01T16:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T06:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T15:30:01Z', true
+        );
+
+        // Run until the limit is reached
+        harness.expectState('2024-06-01T15:30:02Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+        harness.expectState('2024-06-01T16:00:05Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+
+        // Run before the limit is over
+        harness.expectState('2024-06-01T16:00:06Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+        harness.expectState('2024-06-01T23:59:59Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED);
+
+        // After the limit is over, GetEstimatedTimes will return new values of
+        // the current and next sessions, so change the values accordingly
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-02T00:00:00Z',
+            false,
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T06:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-03T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-03T06:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-02T00:00:01Z', true
+        );
+
+        // Run after the limit is over
+        harness.expectState('2024-06-02T00:00:02Z', timeLimitsManager, TimeLimitsState.ACTIVE);
+
+        harness.shutdownManager('2024-06-02T01:00:00Z', timeLimitsManager);
+
+        harness.run();
+    });
+
+    it('records usage correctly to the malcontent timer daemon', () => {
+        const harness = new TestHarness({
+            'org.gnome.desktop.screen-time-limits': {
+                'history-enabled': false,
+                'daily-limit-enabled': true,
+                'daily-limit-seconds': 4 * 60 * 60,
+            },
+        }, null, true);
+        harness.initializeMockClock('2024-06-01T10:00:00Z');
+        harness.addTimerChildProxyEstimatedTimesChangedEvent(
+            '2024-06-01T10:00:01Z',
+            false,
+            TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-01T20:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'),
+            TestHarness.timeStrToSecs('2024-06-02T10:00:00Z')
+        );
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T10:00:02Z', true
+        );
+        const timeLimitsManager = harness.createTimeLimitsManager();
+
+        // Session limits changed
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T11:00:00Z', true
+        );
+        harness.expectUsage('2024-06-01T11:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T11:00:00Z');
+
+        // User state changed
+        harness.addLoginUserStateChangeEvent('2024-06-01T12:00:00Z', 'lingering', true);
+        harness.expectUsage('2024-06-01T12:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T12:00:00Z');
+
+        harness.addLoginUserStateChangeEvent('2024-06-01T13:00:00Z', 'active', false);
+        harness.expectUsage('2024-06-01T13:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T12:00:00Z');
+
+        harness.addLoginUserStateChangeEvent('2024-06-01T14:00:00Z', 'offline', true);
+        harness.expectUsage('2024-06-01T14:00:01Z', '2024-06-01T13:00:00Z', '2024-06-01T14:00:00Z');
+
+        harness.addLoginUserStateChangeEvent('2024-06-01T15:00:00Z', 'active', false);
+        harness.expectUsage('2024-06-01T15:00:01Z', '2024-06-01T13:00:00Z', '2024-06-01T14:00:00Z');
+
+        // Disable wellbeing & parental
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T16:00:00Z', false
+        );
+        harness.expectUsage('2024-06-01T16:00:01Z', '2024-06-01T15:00:00Z', '2024-06-01T16:00:00Z');
+
+        harness.addParentalControlsManagerSessionLimitsChangedEvent(
+            '2024-06-01T17:00:00Z', true
+        );
+        harness.expectUsage('2024-06-01T17:00:01Z', '2024-06-01T15:00:00Z', '2024-06-01T16:00:00Z');
+
+        // Shutdown
+        harness.shutdownManager('2024-06-01T18:00:00Z', timeLimitsManager);
+        harness.expectUsage('2024-06-01T18:00:00Z', '2024-06-01T17:00:00Z', '2024-06-01T18:00:00Z');
+
+        harness.run();
+    });
+
     it('tracks usage correctly from an existing history file', () => {
         const harness = new TestHarness({
             'org.gnome.desktop.screen-time-limits': {